提示:这是基于对付费媒体资源网站的架构分析,总结笔记后,最后由ai完善的.只是设计方案,没有实践过,不要直接拿到公司使用
技术栈:Vue 3 + JavaScript / Gin (Go) / MySQL / Redis / AWS S3
版本:v1.0
日期:2026-09-13
本项目建设一个图片与视频付费资源网站。未购买用户只允许查看图片缩略图/水印图和几秒钟的视频预览;用户支付成功后,刷新或重新进入页面,后端重新查询购买状态,允许查看高清图片或播放完整视频。
安全控制不依赖 Vue 的 v-if。Vue 只负责界面状态;真正的资源权限由 Gin + MySQL + Redis + AWS S3 决定。
浏览器只要已经显示图片或播放视频,就不能从技术上绝对阻止截图、录屏或抓取已下发数据。本方案的目标是:未购买用户拿不到高清原图、完整视频、AES Key 或长期有效的私有资源地址。
| 层 | 技术 | 职责 |
|---|---|---|
| 前端 | Vue 3 + JavaScript | 页面、预览、支付后刷新、高清查看、hls.js 播放、错误提示 |
| 后端 | Gin + Go | JWT、购买鉴权、Play Token、m3u8 重写、AES Key API、S3 Presign |
| 数据库 | MySQL | 用户、媒体、订单、购买授权、AES Key 元数据 |
| 缓存 | Redis | JWT Token 和视频 Play Token,TTL 均为 6 小时 |
| 对象存储 | AWS S3 | 缩略图、预览视频、高清图片、m3u8、.ts 分片 |
| 加速 | S3 Transfer Acceleration | 使用 AWS 默认加速端点 |
| 播放器 | hls.js | 加载 HLS、请求 Key/Segment,交给浏览器媒体栈解密播放 |
启用 Transfer Acceleration 后使用:
https://<bucket-name>.s3-accelerate.amazonaws.com/<object-key>
Bucket 需要显式启用 Transfer Acceleration;Bucket 名称不能包含句点 .。私有对象不设置公开读,Gin 使用 AWS SDK for Go v2 生成短期 Presigned GET URL。
flowchart LR
U[浏览器 / Vue3] -->|HTTPS API| G[Gin API]
G --> M[(MySQL)]
G --> R[(Redis)]
G -->|AWS SDK for Go v2| S[(AWS S3 Private Bucket)]
U -->|302 后的短期 Presigned URL| S
P[支付平台] -->|Webhook| G
v-if 只控制界面,不作为权限判断。s3://paid-media-bucket/
|-- preview/
| |-- image/<media-id>/thumbnail.webp
| `-- video/<media-id>/preview-8s.mp4
|-- original/
| `-- image/<media-id>/original.jpg
`-- hls/
`-- <media-id>/
|-- master.m3u8
|-- index_1080p.m3u8
|-- seg_00001.ts
|-- seg_00002.ts
`-- ...
图片预览建议为低分辨率或带水印版本;视频预览建议单独生成 5~10 秒 MP4。预览资源也可以保持私有,通过短期 Presigned URL 返回。
高清图片和完整 HLS 视频全部放在私有对象路径中,不允许匿名访问。
本方案使用:
users
media
orders
user_media_purchase
aes_key_table
其中 orders 保存交易历史,user_media_purchase 保存当前访问授权。这样支付失败、重复支付、退款等订单历史不会影响权限查询。
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NULL,
password_hash VARCHAR(255) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_users_username (username),
UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE media (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
media_type ENUM('IMAGE','VIDEO') NOT NULL,
price DECIMAL(10,2) NOT NULL,
preview_object_key VARCHAR(512) NOT NULL,
original_object_key VARCHAR(512) NULL,
hls_master_object_key VARCHAR(512) NULL,
preview_seconds INT NULL,
status ENUM('DRAFT','ONLINE','OFFLINE') NOT NULL DEFAULT 'DRAFT',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
KEY idx_media_type_status (media_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
media_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status ENUM('PENDING','PAID','FAILED','REFUNDED','CANCELLED') NOT NULL,
payment_provider VARCHAR(32) NULL,
provider_trade_no VARCHAR(128) NULL,
paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_orders_order_no (order_no),
KEY idx_orders_user_media_status (user_id, media_id, status),
KEY idx_orders_media_status (media_id, status),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_orders_media
FOREIGN KEY (media_id) REFERENCES media(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_media_purchase (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
media_id BIGINT UNSIGNED NOT NULL,
source_order_id BIGINT UNSIGNED NOT NULL,
access_status ENUM('ACTIVE','REVOKED','REFUNDED')
NOT NULL DEFAULT 'ACTIVE',
purchased_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_purchase_user_media (user_id, media_id),
KEY idx_purchase_media_status (media_id, access_status),
CONSTRAINT fk_purchase_user
FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_purchase_media
FOREIGN KEY (media_id) REFERENCES media(id),
CONSTRAINT fk_purchase_order
FOREIGN KEY (source_order_id) REFERENCES orders(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
高频权限查询:
SELECT 1
FROM user_media_purchase
WHERE user_id = ?
AND media_id = ?
AND access_status = 'ACTIVE'
LIMIT 1;
CREATE TABLE aes_key_table (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
media_id BIGINT UNSIGNED NOT NULL,
key_ciphertext VARBINARY(512) NOT NULL,
key_iv VARBINARY(32) NULL,
key_version INT NOT NULL DEFAULT 1,
usage_type ENUM('HLS_AES128','RESERVED_IMAGE') NOT NULL,
status ENUM('ACTIVE','ROTATED','REVOKED') NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_aes_media_version (media_id, key_version),
KEY idx_aes_media_status (media_id, status),
CONSTRAINT fk_aes_media
FOREIGN KEY (media_id) REFERENCES media(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
AES-128 HLS Key 本身是 16 字节。数据库不应保存明文 Key;key_ciphertext 保存由应用主密钥或 AWS KMS 加密后的密文。
如果业务要求“每个媒体都生成 AES Key”,图片也可产生 RESERVED_IMAGE 记录,但普通浏览器图片展示不使用 HLS AES-128;高清图片仍使用 S3 私有对象 + Presigned URL。
关键联合索引:
KEY idx_orders_user_media_status (user_id, media_id, status)
用于快速定位某用户对某媒体的订单记录。
UNIQUE KEY uk_purchase_user_media (user_id, media_id)
用于快速判断当前是否拥有媒体访问权。
KEY idx_aes_media_status (media_id, status)
用于快速读取某媒体当前 ACTIVE AES Key。
orders 和 user_media_purchase 同时存在的目的:
orders 是交易历史;user_media_purchase 是授权状态;REFUNDED/REVOKED。Key: auth:jwt:<jti>
Value: user_id / session metadata
TTL: 21600 seconds
JWT 建议包含:
{
"sub": 10001,
"jti": "random-jwt-id",
"type": "access",
"exp": "6小时后"
}
Gin 鉴权顺序:
exp;auth:jwt:<jti>;Key: video:play:<token>
Value: {"user_id":10001,"media_id":123,"permission":"PLAY"}
TTL: 21600 seconds
JWT 表示“用户是谁”,Play Token 表示“这个用户现在被授权播放哪个视频”。
flowchart TD
A[Vue 打开媒体详情页] --> B[GET /api/v1/media/:id]
B --> C[Gin 读取 media]
C --> D{用户存在 ACTIVE 授权?}
D -- 否 --> E[返回预览资源 purchased=false]
D -- 是 --> F[返回预览资源 purchased=true]
E --> G[缩略图 / 5~10秒预览]
F --> H[显示高清/完整播放按钮]
详情接口不要提前返回完整视频地址、高清图片地址或 AES Key。
sequenceDiagram
participant V as Vue3
participant G as Gin
participant P as 支付平台
participant M as MySQL
V->>G: 创建订单
G->>M: orders=PENDING
V->>P: 完成支付
P->>G: Webhook
G->>P: 验签/金额/订单号
G->>M: orders=PAID
G->>M: user_media_purchase=ACTIVE
V->>G: 支付后跳转/刷新详情页
G-->>V: purchased=true
前端支付回调不能直接赋予访问权限。必须以服务端 Webhook 验证并写入 MySQL 后的状态为准。
flowchart LR
A[Vue] --> B[媒体详情API]
B --> C[短期 S3 预览 Presigned URL]
C --> D[img 显示]
D --> E[禁止右键/拖拽]
Vue:
<img :src="previewUrl" @contextmenu.prevent draggable="false" />
右键限制只是 UI 限制,预览图必须本身就是低清/水印版本。
sequenceDiagram
participant V as Vue3
participant G as Gin
participant M as MySQL
participant S as AWS S3
V->>G: POST /media/123/image-access
G->>M: 查询 user_media_purchase
M-->>G: ACTIVE
G->>S: Presign GetObject
G-->>V: 短期 imageUrl/downloadUrl
V->>S: GET Presigned URL
S-->>V: 高清图片
未购买用户只播放单独生成的 5~10 秒预览文件:
preview/video/<media-id>/preview-8s.mp4
预览 MP4 与完整 HLS 分离。
<video controls controlsList="nodownload" @contextmenu.prevent></video>
完整视频转码后:
master.m3u8
index_1080p.m3u8
seg_00001.ts
seg_00002.ts
...
m3u8 中:
#EXT-X-KEY:METHOD=AES-128,URI="/api/v1/videos/123/key?play_token=..."
正确职责:
hls.js
-> 请求 m3u8
-> 请求 AES Key
-> 请求加密 .ts
-> hls.js/浏览器媒体栈完成 AES-128 解密
-> <video> 播放
sequenceDiagram
participant V as Vue3+hls.js
participant G as Gin
participant M as MySQL
participant R as Redis
participant S as S3 Accelerate
V->>G: POST /videos/123/play + JWT
G->>M: 查询购买授权
M-->>G: ACTIVE
G->>R: SET PlayToken EX 21600
G-->>V: playToken + manifestUrl
V->>G: GET manifest?play_token=TOKEN
G->>R: 校验 PlayToken
G->>S: 读取 m3u8
G-->>V: 重写后的 m3u8
V->>G: GET key?play_token=TOKEN
G->>R: 校验 Token
G->>M: 读取 AES Key
G-->>V: 16-byte Key
loop 每个 .ts
V->>G: GET segment/N?play_token=TOKEN
G->>R: 校验 Token
alt 有效
G-->>V: 302 -> S3 Presigned URL
V->>S: GET segment.ts
S-->>V: AES 加密分片
else 过期
G-->>V: PLAY_TOKEN_EXPIRED
end
end
S3 无法读取 Redis 中的 Play Token,所以分片先访问 Gin:
Browser -> Gin Segment API
|
+-- Redis 校验 Play Token
|
`-- 302 Location: S3短期Presigned URL
Browser -> S3 -> 获取真实.ts数据
这样 Gin 不承担视频字节带宽。
Gin 返回给 hls.js 的内容类似:
#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/api/v1/videos/123/key?play_token=TOKEN"
#EXTINF:6.000,
https://api.example.com/api/v1/videos/123/segments/00001?play_token=TOKEN
#EXTINF:6.000,
https://api.example.com/api/v1/videos/123/segments/00002?play_token=TOKEN
Query 中的 Token 不应写入普通访问日志或第三方分析日志。
每个分片都校验:
Redis GET video:play:<token>
不存在 -> PLAY_TOKEN_EXPIRED
media_id 不匹配 -> FORBIDDEN
存在且匹配 -> 302 S3 Presigned URL
返回:
{
"code": "PLAY_TOKEN_EXPIRED",
"message": "视频播放令牌已过期,请刷新页面后重新播放。"
}
前端:
flowchart TD
A[Request] --> B[Recovery / Logger / CORS]
B --> C{需要登录?}
C -- 是 --> D[JWT Middleware]
D --> E[签名 + exp + Redis jti]
E --> F{视频资源?}
F -- 是 --> G[PlayToken Guard]
G --> H[Redis校验 user/media/TTL]
H --> I[Handler]
F -- 否 --> I
C -- 否 --> I
推荐目录:
server/
|-- cmd/api/
|-- internal/
| |-- handler/
| |-- middleware/
| |-- service/
| |-- repository/
| |-- model/
| |-- cache/
| |-- storage/s3/
| |-- crypto/
| `-- config/
`-- migrations/
src/
|-- api/
| |-- auth.js
| |-- media.js
| |-- order.js
| `-- video.js
|-- views/
| |-- MediaDetail.vue
| `-- PaymentResult.vue
|-- components/
| |-- ImagePreview.vue
| |-- VideoPreview.vue
| `-- PaidVideoPlayer.vue
|-- stores/auth.js
`-- utils/hls-player.js
| Method | Path | 说明 |
|---|---|---|
| POST | /api/v1/auth/login |
登录,JWT Redis TTL 6h |
| POST | /api/v1/auth/logout |
删除 JWT 会话 |
| GET | /api/v1/media/:id |
媒体详情、预览、purchased |
| POST | /api/v1/media/:id/image-access |
高清图片地址 |
| POST | /api/v1/orders |
创建订单 |
| GET | /api/v1/orders/:orderNo/status |
订单状态 |
| POST | /api/v1/payment/webhook |
支付回调 |
| POST | /api/v1/videos/:id/play |
创建 Play Token |
| GET | /api/v1/videos/:id/manifest |
返回重写 m3u8 |
| GET | /api/v1/videos/:id/key |
返回 AES Key |
| GET | /api/v1/videos/:id/segments/:seq |
校验 Token 后 302 S3 |
aes_key_table;播放时:
Key API
-> Play Token校验
-> 查询ACTIVE Key
-> 服务端解密key_ciphertext
-> 返回16-byte Key
后续可以通过 key_version 做 Key Rotation。
JWT Token: 6小时
Play Token: 6小时
高清图片URL: 建议数分钟
Segment S3 URL: 建议1~5分钟
业务 Token 与 S3 对象签名 URL 是两个不同的权限层。
| HTTP | code | 场景 |
|---|---|---|
| 401 | JWT_EXPIRED |
JWT 过期 |
| 401/419 | PLAY_TOKEN_EXPIRED |
Play Token 过期 |
| 403 | MEDIA_NOT_PURCHASED |
未购买 |
| 403 | PLAY_TOKEN_MEDIA_MISMATCH |
Token 与媒体不匹配 |
| 404 | MEDIA_NOT_FOUND |
媒体不存在 |
| 409 | ORDER_STATE_CONFLICT |
订单状态冲突 |
| 500 | S3_PRESIGN_FAILED |
S3 签名失败 |
| 500 | AES_KEY_LOAD_FAILED |
AES Key 读取/解密失败 |
| 风险 | 对策 |
|---|---|
| 未购买访问原图 | S3 私有 + 购买鉴权 + Presigned URL |
| 未购买访问完整视频 | /play 前查询授权 |
| 分享 m3u8 | Play Token 绑定 user/media |
| 分享分片 | Segment API 每次查 Redis |
| 直接访问 AES Key | Key API 强制 Play Token |
| JWT 被注销后继续使用 | 删除 Redis JWT 会话 |
| 前端篡改 purchased | 高清/播放 API 再次服务端鉴权 |
| 固定 S3 URL 泄露 | 私有对象 + 短时 Presigned URL |
| AES Key 表泄露 | Key 保存密文 |
| 日志泄露 | Token/URL/Key 脱敏 |
mindmap
root((付费媒体站))
用户
JWT
Redis 6h
媒体
图片预览
高清原图
短视频预览
HLS完整视频
支付
orders
Webhook
user_media_purchase
视频安全
Play Token
AES-128
Key API
分片鉴权
302 S3
存储
S3 Private
Transfer Acceleration
stateDiagram-v2
[*] --> Preview
Preview --> Paying: 点击购买
Paying --> Preview: 支付失败
Paying --> Paid: 服务端确认
Paid --> Authorized: 点击完整播放
Authorized --> Playing: Token有效
Playing --> Playing: 下一个分片
Playing --> TokenExpired: Token过期
TokenExpired --> Preview: 刷新
Preview --> Paid: 重新查询已购
user_media_purchase。/play 返回 403。orders=PAID。user_media_purchase=ACTIVE。purchased=true。Vue3
|
| JWT
v
Gin API ------------------------- MySQL
| users
| media
| orders
| user_media_purchase
| aes_key_table
|
+------------------------------ Redis
| JWT TTL 6h
| Play Token TTL 6h
|
+-- 图片: 鉴权 -> S3 Presigned URL
|
`-- 视频:
/play -> 购买鉴权 -> Play Token
/manifest -> 重写m3u8
/key -> AES Key
/segment -> Token校验 -> 302 S3
|
v
AWS S3 Private Bucket
s3-accelerate endpoint